最近寒假寫專案寫著發閒,發現React一些套件的教學很少,例如本篇提到的lexical,因此想記錄一下自己的學習心得和想法,如果可以幫助到各位的話就太好啦,第一次寫文章,算是現學現賣,如果有錯誤或可優化的地方,歡迎提出!
使用環境: "@lexical/react": "0.12.5", "@mui/material": "5.14.17",
本篇算是將自己專案的東西複製過來,這裡會提到的範例程式碼都在這裡
Lexical 是基於JS的富文字編輯器,個人認為,對React使用者而言,非常方便,有以下優缺點
相較於其他文字編輯器的套件(例如: Quill, Draft等),不受限於套件本身的樣式與服務限制,他提供更客製化的服務,他可以套用任何自己已經寫好的ui樣式,然後再加上lexical的一些功能,就可以做出符合自己網頁的風格樣式編輯器,同時,又提供許多擴充功能( 例如youtube, latex公式等,可以到他的Playground看),然而如此自製的缺點就是上手有點困難,要產生出一個能用基礎編輯器功能,相較於其他套件就會十分困難。
Lexical中,會使用Node來呈現元件,並且為編譯成html,以下舉常見的例子
<h1>
, <h1>
,<li>
<blockquote>
不只有以上Node,lexical甚至提供開發者可以創建Node,提供更多新花樣,但本文不會提到這裡
前面有提到,Lexical的高可擴充性,是用LexicalComposer
將一個個Plugin加入而來的,例如一個基礎的編輯器就需要有以下功能
除了以上Plugin,還有許多功能,例如清單、Markdown、歷史紀錄(ctrl+z)等功能都是可以自己依照需求增減
lexical上手困難的在於,本身文檔對於這些Plugin敘述很少,大多數時候都是我自己去Playground的原始碼看才知道怎麼達到想要的功能
編輯本身的控制器,可以設定文字編輯器的css class名稱,風格、功能等
<LexicalComposer
initialConfig={editorConfig}>
{/* Plugins here*/}
</LexicalComposer>
const editorConfig = {
namespace: "MyEditor",
theme: {
ltr: "ltr",
rtl: "rtl",
placeholder: "editor-placeholder",
paragraph: "editor-paragraph",
quote: "editor-quote",
heading: {
h1: "editor-heading-h1",
h2: "editor-heading-h2",
h3: "editor-heading-h3",
h4: "editor-heading-h4",
h5: "editor-heading-h5",
},
// ... other classname
},
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
// ...other node
]
}
富文字功能
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
<RichTextPlugin
contentEditable={
<ContentEditable
style={{
padding: "0 8px",
minHeight: "300px",
border: "1px solid #0f0f0f"
borderRadius: "0.3em",
}}
/>
}
ErrorBoundary={LexicalErrorBoundary}
placeholder={null}
/>
自訂義功能列,麻煩在於,需要手動註冊使用者變更選取範圍、更新時的事件,這裡將文字編輯器內的事件區分成三種:
變更文字本身的樣式(例如粗體、斜體等)流程如下
// import {TextFormatType} from 'lexical';
// ...
// const [textFormats, setTextFormats] = useState<TextFormatType[]>([]);
// ...
<ToggleButtonGroup
size="small"
aria-label="text formatting"
//使用useState儲存format值
value={textFormats}
>
<ToggleButton
value="bold"
aria-label="bold"
onClick={handleTextFormatClick}
>
<FormatBoldIcon />
</ToggleButton>
<ToggleButton
value="italic"
aria-label="italic"
onClick={handleTextFormatClick}
>
<FormatItalicIcon />
</ToggleButton>
{/* ... */}
</ToggleButtonGroup>
FORMAT_TEXT_COMMAND
是lexical開給這個事件用的命令常數,可以輸入value
// import {FORMAT_TEXT_COMMAND} from 'lexical';
/** Handle text formatting */
const handleTextFormatClick = (
event: React.MouseEvent,
value: TextFormatType
) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, value);
};
// import {SELECTION_CHANGE_COMMAND} from "lexical"
// import {mergeRegister} from "@lexical/utils"
/** Regist command **/
useEffect(() => {
return mergeRegister(
// 將一般command 註冊到editor中
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar();
});
}),
// 當使用者選取範圍變更時,也需要更新控制列
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
updateToolbar();
return false;
},
LowPriority
)
);
}, [editor, updateToolbar]);
// import { $getSelection, $isRangeSelection} from "@lexical/selection";
/** Toolbar state update */
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Set text Format
const currentFormats: TextFormatType[] = [];
selection.hasFormat("bold") && currentFormats.push("bold");
selection.hasFormat("italic") && currentFormats.push("italic");
selection.hasFormat("underline") && currentFormats.push("underline");
selection.hasFormat("code") && currentFormats.push("code");
selection.hasFormat("strikethrough") &&
currentFormats.push("strikethrough");
setTextFormats(currentFormats);
}
}, [editor]);
目前大致完成基礎的變更樣式功能,我認為,使用lexcial很像在使用jQuery,開發者可以在自己的原件上,附加lexical提供的一些函數,雖然許多功能實現需要自己慢慢加上,但提供了更高客製化的服務。
影響一整行的樣式,例如: 標題、Quote,其流程與textFormat類似,如下:
graph TD;
A[Select: 使用者點擊樣式更新按鈕] --> B[handleBlockFormat:在編輯器更新文字樣式,並註冊變更樣式的命令]
B --> C[useEffect: 聆聽 editor 變更命令]
C --> D[updateToolbar: 變更控制列狀態與編輯器元件]
const supportedBlockTypes = [
"paragraph",
"quote",
"code",
"h1",
"h2",
"ul",
"ol",
];
{supportedBlockTypes.includes(blockType) && (
<>
<FormControl sx={{ m: 1, minWidth: 120 }} size="small">
<Select
value={blockType}
onChange={handleBlockFormat}
displayEmpty
inputProps={{ "aria-label": "Without label" }}
>
{supportedBlockTypes.map((s) => (
<MenuItem value={s} key={`blockType-${s}`}>
{
blockTypeToBlockName[
s as keyof typeof blockTypeToBlockName
]
}
</MenuItem>
))}
</Select>
</FormControl>
</>
)}
/** Handle block formatting */
const blockHandlers = {
paragraph: () => $createParagraphNode(),
h1: () => $createHeadingNode("h1"),
h2: () => $createHeadingNode("h2"),
quote: () => $createQuoteNode(),
code: () => $createCodeNode(),
};
type blockHandlersType = keyof typeof blockHandlers;
const listHandlers = {
ul: INSERT_UNORDERED_LIST_COMMAND,
ol: INSERT_ORDERED_LIST_COMMAND,
};
type listHandlersType = keyof typeof listHandlers;
const handleBlockFormat = (event: SelectChangeEvent) => {
const type = event?.target.value as blockHandlersType | listHandlersType;
if (Object.keys(blockHandlers).includes(type) && blockType !== type) {
editor.update(() => {
const blockHandler = blockHandlers[type as blockHandlersType];
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setBlocksType(selection, blockHandler as () => ElementNode);
}
});
} else if (Object.keys(listHandlers).includes(type)) {
if (blockType !== type) {
editor.dispatchCommand(
listHandlers[type as listHandlersType],
undefined
);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
}
};
/** Toolbar state update */
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Set Block Format
const anchorNode = selection.anchor.getNode();
const element =
anchorNode.getKey() === "root"
? anchorNode
: anchorNode.getTopLevelElementOrThrow();
const elementKey = element.getKey();
const elementDOM = editor.getElementByKey(elementKey);
if (elementDOM !== null) {
if ($isListNode(element)) {
const parentList = $getNearestNodeOfType(anchorNode, ListNode);
const type = parentList ? parentList.getTag() : element.getTag();
setBlockType(type);
} else {
const type = $isHeadingNode(element)
? element.getTag()
: element.getType();
setBlockType(type);
}
}
//...
}
}
與上述兩種流程類似,這裡快速帶過
{/* Elemnet Align format */}
<ControlButtonGroup
size="small"
value={elementFormat}
exclusive
onChange={handleElementFormatClick}
aria-label="text alignment"
>
<ToggleButton value="left" aria-label="left aligned">
<FormatAlignLeftIcon />
</ToggleButton>
<ToggleButton value="center" aria-label="centered">
<FormatAlignCenterIcon />
</ToggleButton>
<ToggleButton value="right" aria-label="right aligned">
<FormatAlignRightIcon />
</ToggleButton>
<ToggleButton value="justify" aria-label="justified">
<FormatAlignJustifyIcon />
</ToggleButton>
</ControlButtonGroup>
/** Handle element align formatting */
const handleElementFormatClick = (
event: React.MouseEvent,
value: ElementFormatType
) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, value);
};
/** Toolbar state update */
export function getSelectedNode(
selection: RangeSelection
): TextNode | ElementNode {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
if (anchorNode === focusNode) {
return anchorNode;
}
const isBackward = selection.isBackward();
if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
} else {
return $isAtNodeEnd(anchor) ? anchorNode : focusNode;
}
}
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Set Algin Format
const node = getSelectedNode(selection);
const parent = node.getParent();
let matchingParent;
if ($isLinkNode(parent)) {
// If node is a link, we need to fetch the parent paragraph node to set format
matchingParent = $findMatchingParent(
node,
(parentNode) => $isElementNode(parentNode) && !parentNode.isInline()
);
}
setElementFormat(
$isElementNode(matchingParent)
? matchingParent.getFormatType()
: $isElementNode(node)
? node.getFormatType()
: parent?.getFormatType() || "left"
);
//...
}
}
至此,lexical最難處理的部分已經完成,剩下的就是加上一些Plugins,大部分的Plugin lexical都有提供,但少部分可以從lexical的Playground中直接複製而來,接下來我會提到一些我專案中有用到,但無法直接import的Plugin,都是抄來的
顧名思義,就是初始化編輯器內容
import { InitialEditorStateType } from "@lexical/react/LexicalComposer";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import React from "react";
const HISTORY_MERGE_OPTIONS = { tag: "history-merge" };
type InitialPluginProps = {
initialEditorState?: InitialEditorStateType;
};
export default function InitialPlugin({
initialEditorState,
}: InitialPluginProps) {
const [editor] = useLexicalComposerContext();
React.useLayoutEffect(() => {
if (initialEditorState !== null) {
try {
switch (typeof initialEditorState) {
case "string": {
const parsedEditorState =
editor.parseEditorState(initialEditorState);
editor.setEditorState(parsedEditorState, HISTORY_MERGE_OPTIONS);
break;
}
case "object": {
editor.setEditorState(initialEditorState, HISTORY_MERGE_OPTIONS);
break;
}
}
} catch (e) {
console.error(e);
}
}
}, [initialEditorState, editor]);
return null;
}
需要加入這個Plugin才可以讓清單能夠縮行與限制縮行次數
import type { RangeSelection } from "lexical";
import { $getListDepth, $isListItemNode, $isListNode } from "@lexical/list";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
$getSelection,
$isElementNode,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
ElementNode,
INDENT_CONTENT_COMMAND,
} from "lexical";
import { useEffect } from "react";
type Props = Readonly<{
maxDepth: number | null | undefined;
}>;
function getElementNodesInSelection(
selection: RangeSelection
): Set<ElementNode> {
const nodesInSelection = selection.getNodes();
if (nodesInSelection.length === 0) {
return new Set([
selection.anchor.getNode().getParentOrThrow(),
selection.focus.getNode().getParentOrThrow(),
]);
}
return new Set(
nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow()))
);
}
function isIndentPermitted(maxDepth: number): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const elementNodesInSelection: Set<ElementNode> =
getElementNodesInSelection(selection);
let totalDepth = 0;
for (const elementNode of elementNodesInSelection) {
if ($isListNode(elementNode)) {
totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth);
} else if ($isListItemNode(elementNode)) {
const parent = elementNode.getParent();
if (!$isListNode(parent)) {
throw new Error(
"ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent."
);
}
totalDepth = Math.max($getListDepth(parent) + 1, totalDepth);
}
}
return totalDepth <= maxDepth;
}
export default function ListMaxIndentLevelPlugin({ maxDepth }: Props): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
INDENT_CONTENT_COMMAND,
() => !isIndentPermitted(maxDepth ?? 7),
COMMAND_PRIORITY_CRITICAL
);
}, [editor, maxDepth]);
return null;
}
避免Tabout出編輯器,基本上如果要讓清單縮行,一定要加這個才能用Tab縮行
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
$getSelection,
$isRangeSelection,
$setSelection,
FOCUS_COMMAND,
} from 'lexical';
import {useEffect} from 'react';
const COMMAND_PRIORITY_LOW = 1;
const TAB_TO_FOCUS_INTERVAL = 100;
let lastTabKeyDownTimestamp = 0;
let hasRegisteredKeyDownListener = false;
function registerKeyTimeStampTracker() {
window.addEventListener(
'keydown',
(event: KeyboardEvent) => {
// Tab
if (event.key === 'Tab') {
lastTabKeyDownTimestamp = event.timeStamp;
}
},
true,
);
}
export default function TabFocusPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!hasRegisteredKeyDownListener) {
registerKeyTimeStampTracker();
hasRegisteredKeyDownListener = true;
}
return editor.registerCommand(
FOCUS_COMMAND,
(event: FocusEvent) => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
if (
lastTabKeyDownTimestamp + TAB_TO_FOCUS_INTERVAL >
event.timeStamp
) {
$setSelection(selection.clone());
}
}
return false;
},
COMMAND_PRIORITY_LOW,
);
}, [editor]);
return null;
}
最後編輯器再加入一些雜七雜八的內建Plugin會長這樣
import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { AutoLinkNode, LinkNode } from "@lexical/link";
import { ListItemNode, ListNode } from "@lexical/list";
import { MarkNode } from "@lexical/mark";
import { TRANSFORMERS } from "@lexical/markdown";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import {
InitialEditorStateType,
LexicalComposer,
} from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin";
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { Box, useTheme } from "@mui/material";
import lexicalTheme from "./theme";
import { EditorState, LexicalEditor } from "lexical";
import React from "react";
import InitialPlugin from "./InitialPlugin";
import ListMaxIndentLevelPlugin from "./ListMaxIndentLevelPlugin";
import TabFocusPlugin from "./TabFocusPlugin";
import ToolbarPlugin from "./ToolbarPlugin";
const editorConfig = {
// The editor theme
namespace: "MyEditor",
theme: lexicalTheme,
// Handling of errors during update
onError(error: any) {
throw error;
},
// Any custom nodes go here
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
MarkNode,
LinkNode,
AutoLinkNode,
],
};
type RichTextEditorProps = {
controllable?: boolean;
onChange?: (
editorState: EditorState,
editor: LexicalEditor,
tags: Set<string>,
) => void;
initialEditorState?: InitialEditorStateType;
};
function RichTextEditor({
controllable = true,
onChange,
initialEditorState,
}: RichTextEditorProps) {
const theme = useTheme();
return (
<Box sx={{ position: "relative" }}>
<LexicalComposer
initialConfig={{
...editorConfig,
editable: controllable,
}}
>
<InitialPlugin initialEditorState={initialEditorState} />
{controllable ? (
<>
<ToolbarPlugin />
<LinkPlugin />
<HistoryPlugin />
<TabIndentationPlugin />
<ListPlugin />
<AutoFocusPlugin />
<TabFocusPlugin />
<ListMaxIndentLevelPlugin maxDepth={3} />
{onChange ? (
<OnChangePlugin
onChange={onChange}
ignoreSelectionChange
></OnChangePlugin>
) : (
<React.Fragment />
)}
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
</>
) : (
<React.Fragment />
)}
<RichTextPlugin
contentEditable={
<ContentEditable
style={{
padding: "0 8px",
minHeight: controllable ? "300px" : "auto",
border: controllable
? `1px solid ${theme.palette.divider}`
: "",
borderRadius: "0.3em",
fontFamily: theme.typography.fontFamily,
}}
/>
}
ErrorBoundary={LexicalErrorBoundary}
placeholder={null}
/>
</LexicalComposer>
</Box>
);
}
export default RichTextEditor;
首先,感謝看到這裡的人,這是我第一次寫文章,本人寫React只有1年多一點,菜雞輸出,有任何疏漏歡迎提出,希望能幫助到各位,感謝!